Next.js 페이지가 static이 되기 위한 조건
왜 이걸 신경써야 하나
같은 코드인데 어떤 페이지는 빌드 타임에 HTML로 미리 구워지고(static), 어떤 페이지는 요청이 올 때마다 서버에서 새로 그려진다(dynamic). 이 차이가 어디서 갈리는지 알아보자.
static으로 구워진 페이지는 CDN에 올려두고 그냥 뿌려주면 되니까 빠르고 서버 부하도 없다. 반대로 dynamic 페이지는 요청마다 서버가 렌더링을 돌려야 한다. 그래서 "이 페이지가 왜 static이 안 되지?" 를 디버깅할 일이 자주 생기는데, 그때 판단 기준을 알고 있어야 한다.
이 글은 App Router 기준이며, Next.js 16 문서를 기준으로 작성했다. (cacheComponents를 켜지 않은 기본 모델 기준이다. 이건 맨 아래에서 따로 언급한다.)
기본 원칙: Next.js는 static이 기본이다
App Router에서 페이지는 아무것도 특별한 걸 안 쓰면 static이다. 빌드할 때 Next.js가 최대한 미리 렌더링해서 HTML로 구워둔다.
// 이 페이지는 static이다. 요청 시점 정보가 하나도 없다.
export default function Page() {
return <h1>Hello</h1>;
}
export const dynamic = 'auto' 라는 게 기본값인데, 이건 "가능한 한 최대로 static으로 구우려고 시도한다"는 뜻이다. 그러다가 요청 시점에만 알 수 있는 정보(쿠키, 헤더, URL 쿼리 등)를 건드리는 순간 "아 이건 미리 못 굽겠네" 하고 그 페이지를 dynamic으로 떨어뜨린다.
빌드 결과로 확인하기
next build를 돌리면 각 라우트가 어떻게 렌더링되는지 심볼로 찍어준다. 아래는 이 글의 실습 데모(demo/apps/nextjs-static-conditions)를 빌드한 실제 출력이다. 라우트마다 조건을 하나씩만 걸어둬서 심볼과 조건이 1:1로 대응된다. (오른쪽 ←는 설명용 주석, 핵심 라우트만 발췌)
Route (app) Revalidate Expire
┌ ○ /
├ ○ /a-plain ← 동적 API 없음
├ ƒ /b-cookies ← cookies()
├ ƒ /c-headers ← headers()
├ ƒ /d-searchparams ← searchParams
├ ƒ /e-fetch-uncached ← no-store fetch
├ ○ /f-fetch-cached ← 로컬 데이터 직접 호출
├ ○ /g-isr 1m 1y ← revalidate=60
├ ○ /h-force-static ← force-static + cookies()
├ ƒ /i-force-dynamic ← force-dynamic
├ ● /j-gen/[slug] ← generateStaticParams
│ ├ /j-gen/1
│ ├ /j-gen/2
│ └ /j-gen/3
└ ƒ /k-nogen/[slug] ← gen 없이 params
○ (Static) prerendered as static content
● (SSG) prerendered as static HTML (uses generateStaticParams)
ƒ (Dynamic) server-rendered on demand
○static — 빌드 타임에 통째로 HTML로 구워짐●SSG — 동적 세그먼트([slug])를generateStaticParams로 미리 만들어 구워둔 것. 결국 static이다ƒdynamic — 요청마다 서버 렌더링
즉 페이지가 static이 되었는지 아닌지는 이 빌드 출력만 봐도 바로 확인된다. 아래 이어지는 조건들을 이 데모에서 직접 켜고 끄며 심볼이 바뀌는 걸 확인할 수 있다.
static을 깨뜨리는 것들
그럼 무엇이 페이지를 ƒ dynamic으로 떨어뜨리냐. 크게 세 부류다. 이 중 하나라도 페이지(또는 그 안의 컴포넌트)에서 쓰이면 그 라우트 전체가 dynamic이 된다.
1. Dynamic API (요청 시점 정보)
요청이 실제로 들어와야만 값이 정해지는 API들이다. 빌드 타임엔 "요청"이라는 게 없으니 이걸 쓰는 순간 미리 구울 수가 없다.
import { cookies, headers, draftMode } from "next/headers";
import { connection } from "next/server";
export default async function Page({ searchParams }) {
const cookieStore = await cookies(); // 쿠키
const headersList = await headers(); // 요청 헤더
// draftMode(), connection() 도 마찬가지
// searchParams (URL 쿼리) 를 읽는 것도 dynamic 전환
}
목록으로 정리하면:
cookies()headers()draftMode()connection()(요청이 들어올 때까지 렌더링을 미루는 함수)searchParamsprop (URL 쿼리스트링)paramsprop — 단,generateStaticParams로 샘플 경로를 만들어주면 static으로 구울 수 있다
원리를 조금 더 파보면, 빌드 중(prerender 중)에 cookies() 같은 걸 호출하면 Next.js 내부에서 DynamicServerError를 던지면서 revalidate = 0으로 세팅해버린다. 이 에러가 "정적 생성 중단(bail out)" 신호가 되어서 해당 라우트를 dynamic으로 돌리는 것이다.
2. 캐시되지 않은 fetch
Next.js 15부터 fetch는 기본적으로 캐시되지 않는다(no-store). 예전엔 기본 캐시였는데 바뀌었다. 이게 은근 많이 걸린다.
export default async function Page() {
// Next 15+ 에서는 이 fetch가 기본 no-store → 매 요청 재실행 → 페이지 dynamic
const res = await fetch("https://api.example.com/data");
const data = await res.json();
return <div>{data.title}</div>;
}
static으로 굽고 싶으면 명시적으로 캐시를 켜줘야 한다.
// 캐시 켜기 (한 번 굽고 재사용)
const res = await fetch("https://...", { cache: "force-cache" });
// 혹은 주기적으로 갱신 (ISR)
const res = await fetch("https://...", { next: { revalidate: 3600 } });
fetch를 안 쓰는 DB 직접 호출 등은 unstable_noStore()(또는 connection())를 호출하면 같은 방식으로 dynamic으로 빠진다.
3. Route Segment Config
페이지/레이아웃에서 설정값을 export해서 강제로 dynamic으로 만들 수도 있다.
export const dynamic = "force-dynamic"; // 무조건 dynamic
export const revalidate = 0; // 항상 dynamic 렌더링
export const fetchCache = "force-no-store"; // 모든 fetch를 no-store로
dynamic = 'force-dynamic'은 사실상 그 페이지의 모든 fetch를 { cache: 'no-store' }로 만드는 것과 같다.
static을 지키거나 강제하는 법
반대로 static을 유지하거나 강제하는 방법이다.
그냥 dynamic API를 안 쓴다
가장 기본. 위의 세 부류를 안 쓰면 페이지는 static이다. fetch를 쓴다면 force-cache나 revalidate를 붙여준다.
force-static — 억지로라도 static으로
dynamic = 'force-static'을 주면 cookies(), headers(), useSearchParams()가 에러 대신 빈 값을 반환하도록 만들어서, 강제로 static으로 굽는다.
export const dynamic = "force-static";
// 이 안에서 cookies()를 써도 에러 안 나고 그냥 빈 값이 나옴 → static 유지
요청별 정보를 못 읽게 되는 대신 static을 보장받는 셈이다.
error — static을 어기면 빌드를 깨뜨린다
dynamic = 'error'는 "이 페이지는 반드시 static이어야 한다"고 못을 박는 옵션이다. 만약 안에서 dynamic API나 uncached fetch를 쓰면 빌드 타임에 에러를 낸다. 실수로 static이 깨지는 걸 방지하고 싶을 때 쓴다.
generateStaticParams — 동적 세그먼트를 static으로
[slug] 같은 동적 경로도 어떤 값들이 올지 빌드 타임에 알려주면 static으로 구울 수 있다.
export async function generateStaticParams() {
return [{ slug: "hello" }, { slug: "world" }];
}
export const dynamicParams = false; // 위에서 만든 것 외의 경로는 404
이 방식은 [[Next.js의 SSG를 활용한 마크다운 페이지 렌더링 자동화]]에서 마크다운 경로들을 static 페이지로 굽는 데 실제로 써먹었다. dynamicParams = false를 주면 명시한 경로 외에는 전부 404로 막아서, static으로 구운 경로만 허용하게 된다.
정리: 판단 순서
결국 "이 페이지가 static이 되냐"는 이렇게 따지면 된다.
cookies()/headers()/draftMode()/connection()/searchParams를 쓰나? → 쓰면 dynamicparams를 쓰는데generateStaticParams가 없나? → 없으면 dynamic- 캐시 안 한
fetch(기본 no-store)나unstable_noStore()가 있나? → 있으면 dynamic dynamic = 'force-dynamic'/revalidate = 0을 줬나? → 줬으면 dynamic- 위에 다 해당 안 되면 → static
헷갈리면 그냥 next build 돌려서 심볼(○ / ● / ƒ)을 보는 게 제일 확실하다.
(참고) Next.js 16의 Cache Components
Next.js 16에는 cacheComponents: true 플래그로 켜는 새 모델이 있다. 이걸 켜면 위에서 말한 "페이지 하나가 통째로 static이냐 dynamic이냐"라는 이분법이 사라지고, 한 페이지 안에서 static 부분과 dynamic 부분이 공존한다(부분 프리렌더링, PPR). 페이지는 항상 static shell을 갖고, dynamic한 조각만 <Suspense>로 감싸 요청 시점에 스트리밍하는 방식이다.
다만 이건 옵트인 기능이고 이 글의 기본 모델과는 판단 규칙 자체가 다르므로, 별도 문서에서 따로 다룬다.